atures), 'faq_part_numbers' => $faq_result, ]; } function ada_dib_public_batch(array $batch) { $counts = ['queued' => 0, 'processing' => 0, 'imported' => 0, 'updated' => 0, 'failed' => 0, 'remaining' => 0]; $items = []; foreach ($batch['items'] as $item) { $status = $item['status']; if (isset($counts[$status])) $counts[$status]++; if ($status === 'completed') { $counts[$item['action'] === 'updated' ? 'updated' : 'imported']++; } if (in_array($status, ['queued', 'processing'], true)) $counts['remaining']++; $items[] = [ 'index' => $item['index'], 'status' => $status, 'attempts' => $item['attempts'], 'source_url' => $item['record']['source_url'], 'product_id' => $item['product_id'], 'action' => $item['action'], 'images' => $item['images'] ?? null, 'requested_images' => $item['requested_images'] ?? null, 'failed_images' => $item['failed_images'] ?? [], 'attributes' => $item['attributes'] ?? null, 'faq_part_numbers' => $item['faq_part_numbers'] ?? null, 'stage' => $item['stage'] ?? ($status === 'queued' ? 'Queued' : ucfirst($status)), 'started_at' => $item['started_at'] ?? null, 'completed_at' => $item['completed_at'] ?? null, 'duration_seconds' => $item['duration_seconds'] ?? null, 'error' => $item['error'], ]; } return [ 'id' => $batch['id'], 'status' => $batch['status'], 'draft_only' => true, 'created_at' => $batch['created_at'], 'updated_at' => $batch['updated_at'], 'counts' => $counts, 'items' => $items, ]; } function ada_dib_batch_status(WP_REST_Request $request) { $batch = ada_dib_get_batch($request['id']); return $batch ? ada_dib_public_batch($batch) : new WP_Error('not_found', 'Batch not found.', ['status' => 404]); } function ada_dib_verify_product(WP_REST_Request $request) { $id = (int) $request['id']; $object = wc_get_product($id); if (!$object) return new WP_Error('not_found', 'Product not found.', ['status' => 404]); $attributes = []; foreach ($object->get_attributes() as $attribute) { $attributes[$attribute->get_name()] = $attribute->is_taxonomy() ? wc_get_product_terms($id, $attribute->get_name(), ['fields' => 'names']) : $attribute->get_options(); } $prop65_present = stripos($object->get_description() . ' ' . $object->get_short_description(), 'P65Warnings') !== false || stripos($object->get_description() . ' ' . $object->get_short_description(), 'Proposition 65') !== false; foreach (array_keys($attributes) as $attribute_name) { if (ada_dib_is_prop65($attribute_name)) $prop65_present = true; } $faq_entries = get_post_meta($id, 'faq', true); $faq_index = (int) get_post_meta($id, ADA_DIB_FAQ_INDEX_META, true); $managed_faq = is_array($faq_entries) && isset($faq_entries[$faq_index]) ? $faq_entries[$faq_index] : null; return [ 'id' => $id, 'preview_url' => get_preview_post_link($id), 'status' => get_post_status($id), 'title' => $object->get_name(), 'regular_price' => $object->get_regular_price(), 'sale_price' => $object->get_sale_price(), 'sku' => $object->get_sku(), 'image_count' => ($object->get_image_id() ? 1 : 0) + count($object->get_gallery_image_ids()), 'featured_image_id' => $object->get_image_id(), 'gallery_image_ids' => $object->get_gallery_image_ids(), 'description_present' => (bool) ($object->get_description() || $object->get_short_description()), 'category_count' => count($object->get_category_ids()), 'attributes' => $attributes, 'source_url' => get_post_meta($id, '_ei_product_url', true), 'has_ei_product' => (bool) get_post_meta($id, '_ei_product', true), 'has_ei_product_info' => (bool) get_post_meta($id, '_ei_product_info', true), 'prop65_present' => $prop65_present, 'faq_meta_key' => 'faq', 'faq_entries' => is_array($faq_entries) ? $faq_entries : [], 'managed_faq_index' => $managed_faq === null ? null : $faq_index, 'managed_faq' => $managed_faq, 'draft_only' => get_post_status($id) === 'draft', ]; } function ada_dib_recent_diagnostics() { global $wpdb; $option_names = $wpdb->get_col($wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC LIMIT 20", $wpdb->esc_like(ADA_DIB_BATCH_PREFIX) . '%' )); $batches = []; $product_ids = []; foreach ($option_names as $option_name) { $batch = get_option($option_name); if (!is_array($batch)) continue; $items = []; foreach ((array) ($batch['items'] ?? []) as $item) { $record = (array) ($item['record'] ?? []); $features = (array) ($record['features'] ?? []); $images = (array) ($record['images'] ?? []); $product_id = (int) ($item['product_id'] ?? 0); if ($product_id) $product_ids[] = $product_id; $items[] = [ 'status' => $item['status'] ?? '', 'attempts' => (int) ($item['attempts'] ?? 0), 'product_id' => $product_id ?: null, 'action' => $item['action'] ?? null, 'error' => $item['error'] ?? null, 'source_url' => $record['source_url'] ?? '', 'title' => $record['title'] ?? '', 'images' => array_values($images), 'image_count' => count($images), 'features' => array_values($features), 'feature_count' => count($features), 'ei_image_count' => count((array) (($record['ei_product']['images'] ?? []))), 'ei_feature_count' => count((array) (($record['ei_product']['features'] ?? []))), ]; } $batches[] = [ 'id' => $batch['id'] ?? '', 'status' => $batch['status'] ?? '', 'created_at' => $batch['created_at'] ?? '', 'updated_at' => $batch['updated_at'] ?? '', 'items' => $items, ]; } $recent_ids = array_map('intval', $wpdb->get_col( "SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id=p.ID WHERE p.post_type='product' AND p.post_status='draft' AND pm.meta_key='" . ADA_DIB_SOURCE_META . "' ORDER BY p.post_date_gmt DESC, p.ID DESC LIMIT 20" )); $product_ids = array_values(array_unique(array_merge($product_ids, $recent_ids))); $products = []; foreach ($product_ids as $product_id) { $object = wc_get_product($product_id); if (!$object) continue; $raw_attributes = get_post_meta($product_id, '_product_attributes', true); $ei_product = get_post_meta($product_id, '_ei_product', true); $image_ids = array_values(array_filter(array_merge( [$object->get_image_id()], $object->get_gallery_image_ids() ))); $attachments = []; foreach ($image_ids as $attachment_id) { $attachments[] = [ 'id' => (int) $attachment_id, 'url' => wp_get_attachment_url($attachment_id), 'source_url' => get_post_meta($attachment_id, '_source_url', true), 'mime' => get_post_mime_type($attachment_id), ]; } $products[] = [ 'id' => $product_id, 'created_gmt' => get_post_field('post_date_gmt', $product_id), 'modified_gmt' => get_post_field('post_modified_gmt', $product_id), 'title' => $object->get_name(), 'status' => get_post_status($product_id), 'source_url' => get_post_meta($product_id, '_ei_product_url', true), 'image_id' => $object->get_image_id(), 'gallery_ids' => $object->get_gallery_image_ids(), 'attachments' => $attachments, 'raw_attributes' => is_array($raw_attributes) ? $raw_attributes : [], 'wc_attribute_count' => count($object->get_attributes()), 'ei_features' => is_object($ei_product) ? (array) ($ei_product->features ?? []) : [], 'ei_images' => is_object($ei_product) ? (array) ($ei_product->images ?? []) : [], ]; } return ['ok' => true, 'batches' => $batches, 'products' => $products]; } function ada_dib_faq_diagnostics() { global $wpdb; $rows = $wpdb->get_results( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key='faq' AND meta_value<>'' ORDER BY meta_id DESC LIMIT 10", ARRAY_A ); $existing = []; foreach ($rows as $row) { $existing[] = [ 'product_id' => (int) $row['post_id'], 'value' => maybe_unserialize($row['meta_value']), ]; } $source_matches = []; $roots = [ get_stylesheet_directory(), WP_PLUGIN_DIR . '/enovathemes-addons', ]; foreach ($roots as $root) { if (!is_dir($root)) continue; try { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (count($source_matches) >= 20) break 2; if (!$file->isFile() || strtolower($file->getExtension()) !== 'php' || $file->getSize() > 300000) continue; $content = @file_get_contents($file->getPathname()); if (!$content || stripos($content, 'faq') === false) continue; if (!preg_match('/.{0,240}(?:get_post_meta|cmb2|get_option).{0,160}faq.{0,500}/is', $content, $match) && !preg_match('/.{0,240}faq.{0,300}(?:title|value|accordion).{0,300}/is', $content, $match)) { continue; } $source_matches[] = [ 'file' => str_replace(ABSPATH, '', $file->getPathname()), 'snippet' => preg_replace('/\s+/', ' ', $match[0]), ]; } } catch (Throwable $ignored) { } } return [ 'ok' => true, 'meta_key' => 'faq', 'existing' => $existing, 'source_matches' => $source_matches, ]; } function ada_dib_retry_batch(WP_REST_Request $request) { $batch = ada_dib_get_batch($request['id']); if (!$batch) return new WP_Error('not_found', 'Batch not found.', ['status' => 404]); $batch['cancelled'] = false; foreach ($batch['items'] as &$item) { if (in_array($item['status'], ['failed', 'processing'], true)) { $item['status'] = 'queued'; $item['error'] = null; ada_dib_schedule_item($batch['id'], $item['index']); } } unset($item); $batch['status'] = 'queued'; ada_dib_save_batch($batch); return ada_dib_public_batch($batch); } function ada_dib_cancel_batch(WP_REST_Request $request) { $batch = ada_dib_get_batch($request['id']); if (!$batch) return new WP_Error('not_found', 'Batch not found.', ['status' => 404]); $batch['cancelled'] = true; $batch['status'] = 'cancelled'; foreach ($batch['items'] as &$item) { if ($item['status'] === 'queued') $item['status'] = 'cancelled'; } unset($item); ada_dib_save_batch($batch); return ada_dib_public_batch($batch); } add_action('admin_menu', function () { add_submenu_page('woocommerce', 'ADA Desktop Import', 'ADA Desktop Import', 'manage_woocommerce', 'ada-desktop-import', 'ada_dib_admin_page'); }); add_action('admin_post_ada_dib_save', function () { if (!current_user_can('manage_woocommerce')) wp_die('Unauthorized'); check_admin_referer('ada_dib_save'); $settings = ada_dib_settings(); $settings['enabled'] = !empty($_POST['enabled']); update_option(ADA_DIB_OPTION, $settings, false); wp_safe_redirect(admin_url('admin.php?page=ada-desktop-import&saved=1')); exit; }); add_action('admin_post_ada_dib_rotate', function () { if (!current_user_can('manage_woocommerce')) wp_die('Unauthorized'); check_admin_referer('ada_dib_rotate'); $token = 'ada_' . wp_generate_password(48, false, false); $settings = ada_dib_settings(); $settings['token_hash'] = wp_hash_password($token); $settings['token_hint'] = substr($token, -4); update_option(ADA_DIB_OPTION, $settings, false); ada_dib_admin_page($token); exit; }); function ada_dib_admin_page($one_time_token = '') { if (!current_user_can('manage_woocommerce')) return; $settings = ada_dib_settings(); global $wpdb; $keys = $wpdb->get_col($wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC LIMIT 20", $wpdb->esc_like(ADA_DIB_BATCH_PREFIX) . '%' )); echo '

ADA Desktop Import Bridge

'; if ($one_time_token) { echo '

Copy this token now. It will not be shown again.

'; echo '
'; } echo '

Version ' . esc_html(ADA_DIB_VERSION) . '. Imports are draft-only and hidden from the catalog.

'; echo '
'; wp_nonce_field('ada_dib_save'); echo ''; echo ' '; submit_button('Save', 'primary', 'submit', false); echo '

'; echo '
'; wp_nonce_field('ada_dib_rotate'); echo ''; submit_button($settings['token_hash'] ? 'Rotate API token' : 'Create API token', 'secondary', 'submit', false); if ($settings['token_hint']) echo ' Current token ends in …' . esc_html($settings['token_hint']) . ''; echo '

Recent batches

'; foreach ($keys as $key) { $batch = get_option($key); if (!is_array($batch)) continue; $public = ada_dib_public_batch($batch); $done = $public['counts']['imported'] + $public['counts']['updated'] + $public['counts']['failed']; echo ''; } if (!$keys) echo ''; echo '
BatchStatusUpdatedProgress
' . esc_html($batch['id']) . '' . esc_html($batch['status']) . '' . esc_html($batch['updated_at']) . '' . esc_html($done . '/' . count($batch['items'])) . '
No batches yet.
'; } function ada_dib_bulk_dir() { $uploads = wp_upload_dir(); return trailingslashit($uploads['basedir']) . 'ada-dib-bulk'; } function ada_dib_bulk_secure_dir() { $dir = ada_dib_bulk_dir(); if (!is_dir($dir) && !wp_mkdir_p($dir)) return new WP_Error('storage', 'Secure import storage could not be created.'); if (!file_exists($dir . '/index.php')) @file_put_contents($dir . '/index.php', " ['source_url', '_ei_product_url', 'product_url', 'url'], 'source_domain' => ['source_domain', '_ei_product_domain'], 'title' => ['title', 'product_name', 'name'], 'regular_price' => ['regular_price', 'price', 'current_price'], 'currency' => ['currency', 'currency_code'], 'description' => ['description', 'product_description'], 'images' => ['images', 'image_urls', 'selected_images'], 'category_path' => ['category_path', 'categories'], 'attributes_json' => ['attributes_json', 'attributes', 'features_json'], 'manufacturer' => ['manufacturer', 'brand'], 'manufacturer_number' => ['manufacturer_number', 'manufacturer_part_number', 'mpn'], 'dpi_number' => ['dpi_number', 'replaces_dpi_number'], 'part_numbers_json' => ['part_numbers_json', 'part_numbers'], 'rating_value' => ['rating_value', 'rating'], 'review_count' => ['review_count', 'reviews'], 'in_stock' => ['in_stock', 'stock'], 'ei_product_json' => ['_ei_product_json', 'ei_product_json'], 'make' => ['make'], 'model' => ['model'], 'year' => ['year'], 'submodel' => ['submodel'], 'engine' => ['engine'], 'fit_note' => ['fit_note'], 'vehicle_fit_details' => ['vehicle_fit_details'], 'ignored_sale_price' => ['sale_price', 'old_price', 'list_price', 'compare_price'], 'ignored_woocommerce_type' => ['woocommerce_type'], ]; $normalized = []; foreach ($headers as $index => $header) { $header = trim((string) $header); if ($index === 0) $header = preg_replace('/^\xEF\xBB\xBF/', '', $header); $normalized[$index] = strtolower(preg_replace('/[^a-z0-9_]+/', '_', $header)); } $map = []; foreach ($aliases as $canonical => $names) { foreach ($normalized as $index => $name) { if (in_array($name, $names, true)) { $map[$canonical] = $index; break; } } } $known = array_merge(...array_values($aliases)); $unknown = []; foreach ($normalized as $name) if ($name !== '' && !in_array($name, $known, true)) $unknown[] = $name; $missing = array_values(array_diff(['source_url', 'title', 'regular_price'], array_keys($map))); return [$map, array_values(array_unique($unknown)), $missing, $normalized]; } function ada_dib_bulk_value(array $row, array $map, $key, $default = '') { return isset($map[$key]) ? (string) ($row[$map[$key]] ?? $default) : $default; } function ada_dib_bulk_split($value, $separator = '|') { $value = trim((string) $value); if ($value === '') return []; $json = json_decode($value, true); if (is_array($json)) return array_values(array_filter(array_map('trim', $json), 'strlen')); return array_values(array_filter(array_map('trim', preg_split('/\s*' . preg_quote($separator, '/') . '\s*/', $value)), 'strlen')); } function ada_dib_bulk_record(array $row, array $map) { $attributes = json_decode(ada_dib_bulk_value($row, $map, 'attributes_json', '{}'), true); if (!is_array($attributes)) $attributes = []; foreach (['make' => 'Make', 'model' => 'Model', 'year' => 'Year', 'submodel' => 'Submodel', 'engine' => 'Engine', 'fit_note' => 'Fit Note', 'vehicle_fit_details' => 'Vehicle Fit Details'] as $key => $label) { $value = trim(ada_dib_bulk_value($row, $map, $key)); if ($value !== '' && empty($attributes[$label])) $attributes[$label] = $value; } $features = []; foreach ($attributes as $name => $value) { if (is_array($value)) $value = implode(', ', $value); $features[] = ['name' => (string) $name, 'value' => (string) $value]; } $part_numbers = json_decode(ada_dib_bulk_value($row, $map, 'part_numbers_json', '{}'), true); if (!is_array($part_numbers)) $part_numbers = []; $ei_product = json_decode(ada_dib_bulk_value($row, $map, 'ei_product_json', '{}'), true); if (!is_array($ei_product)) $ei_product = []; $images = ada_dib_bulk_split(ada_dib_bulk_value($row, $map, 'images')); $categories = ada_dib_bulk_split(ada_dib_bulk_value($row, $map, 'category_path'), '>'); return [ 'source_url' => ada_dib_bulk_value($row, $map, 'source_url'), 'source_domain' => ada_dib_bulk_value($row, $map, 'source_domain', 'www.carparts.com'), 'title' => ada_dib_bulk_value($row, $map, 'title'), 'price' => ada_dib_bulk_value($row, $map, 'regular_price'), 'currency' => ada_dib_bulk_value($row, $map, 'currency', 'USD'), 'description' => ada_dib_bulk_value($row, $map, 'description'), 'images' => $images, 'category_path' => $categories, 'features' => $features, 'manufacturer' => ada_dib_bulk_value($row, $map, 'manufacturer'), 'manufacturer_number' => ada_dib_bulk_value($row, $map, 'manufacturer_number'), 'dpi_number' => ada_dib_bulk_value($row, $map, 'dpi_number'), 'part_numbers' => $part_numbers, 'rating_value' => ada_dib_bulk_value($row, $map, 'rating_value'), 'review_count' => ada_dib_bulk_value($row, $map, 'review_count'), 'ei_product' => $ei_product, ]; } function ada_dib_bulk_valid_path(array $job) { $dir = realpath(ada_dib_bulk_dir()); $path = realpath((string) ($job['path'] ?? '')); return $dir && $path && dirname($path) === $dir && is_file($path) ? $path : false; } function ada_dib_bulk_preflight($path) { $handle = fopen($path, 'rb'); if (!$handle) return new WP_Error('csv_open', 'CSV file could not be opened.'); $bom = fread($handle, 3); if ($bom !== "\xEF\xBB\xBF") rewind($handle); $headers = fgetcsv($handle); if (!is_array($headers)) { fclose($handle); return new WP_Error('csv_header', 'CSV header row is missing.'); } list($map, $unknown, $missing, $normalized) = ada_dib_bulk_headers($headers); $data_offset = ftell($handle); fclose($handle); // Fast path: validate headers only. Full row counting is done in short AJAX ticks // so large CSVs do not block PHP-FPM / appear to hang on "Uploading…". return [ 'headers' => $normalized, 'header_map' => $map, 'unknown_headers' => $unknown, 'missing_headers' => $missing, 'total' => 0, 'duplicate_source_urls' => 0, 'data_offset' => $data_offset, 'count_offset' => $data_offset, 'count_complete' => false, ]; } /** * Continue counting CSV rows for a job (bounded ~8s per call). */ function ada_dib_bulk_count_tick(array &$job) { if (!empty($job['count_complete'])) return $job; $path = ada_dib_bulk_valid_path($job); if (!$path) throw new RuntimeException('Stored CSV file is unavailable.'); $handle = fopen($path, 'rb'); if (!$handle) throw new RuntimeException('Stored CSV file could not be opened.'); $offset = (int) ($job['count_offset'] ?? $job['data_offset'] ?? 0); fseek($handle, $offset); $rows = (int) ($job['total'] ?? 0); $deadline = microtime(true) + 8; $scanned = 0; while (microtime(true) < $deadline) { $row = fgetcsv($handle); if ($row === false) { $job['count_complete'] = true; $job['count_offset'] = ftell($handle); $job['stage'] = 'Preflight complete'; ada_dib_bulk_log($job, 'Row count finished: ' . $rows . ' data rows.'); break; } $job['count_offset'] = ftell($handle); if ($row === [null] || !array_filter($row, 'strlen')) continue; $rows++; $scanned++; if ($scanned % 400 === 0) { $job['total'] = $rows; $job['stage'] = 'Counting rows… ' . number_format($rows); } } $job['total'] = $rows; if (empty($job['count_complete'])) { $job['stage'] = 'Counting rows… ' . number_format($rows); } fclose($handle); ada_dib_bulk_save($job); return $job; } function ada_dib_bulk_ajax_guard() { if (!current_user_can('manage_woocommerce')) wp_send_json_error(['message' => 'Unauthorized.'], 403); check_ajax_referer('ada_dib_bulk', 'nonce'); } add_action('wp_ajax_ada_dib_bulk_upload', function () { ada_dib_bulk_ajax_guard(); if (empty($_FILES['csv']) || !is_uploaded_file($_FILES['csv']['tmp_name'])) { wp_send_json_error(['message' => 'Choose a CSV file.'], 400); } $file = $_FILES['csv']; if ((int) $file['size'] < 1 || (int) $file['size'] > ADA_DIB_BULK_MAX_FILE) { wp_send_json_error(['message' => 'CSV must be between 1 byte and 5 MB for a single upload. Larger files upload automatically in resumable chunks.'], 400); } $name = sanitize_file_name((string) $file['name']); if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') { wp_send_json_error(['message' => 'Only .csv files are accepted.'], 400); } if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = $finfo ? (string) finfo_file($finfo, $file['tmp_name']) : ''; if ($finfo) finfo_close($finfo); $allowed_mimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel', 'application/octet-stream']; if ($mime !== '' && !in_array($mime, $allowed_mimes, true)) { wp_send_json_error(['message' => 'The uploaded file is not recognized as CSV text.'], 400); } } $dir = ada_dib_bulk_secure_dir(); if (is_wp_error($dir)) wp_send_json_error(['message' => $dir->get_error_message()], 500); $id = str_replace('-', '', wp_generate_uuid4()); $path = $dir . '/' . $id . '.csv'; if (!move_uploaded_file($file['tmp_name'], $path)) { wp_send_json_error(['message' => 'CSV upload could not be stored.'], 500); } $batch_size = max(1, min(25, (int) ($_POST['batch_size'] ?? 5))); $image_mode = sanitize_key((string) ($_POST['image_mode'] ?? 'deferred')); $job = ada_dib_bulk_create_job_from_path($path, $name, $batch_size, $image_mode); if (is_wp_error($job)) { @unlink($path); wp_send_json_error(['message' => $job->get_error_message()], 400); } wp_send_json_success(['job' => ada_dib_bulk_public($job)]); }); function ada_dib_bulk_create_job_from_path($path, $name, $batch_size, $image_mode) { $preflight = ada_dib_bulk_preflight($path); if (is_wp_error($preflight)) return $preflight; $batch_size = max(1, min(25, (int) $batch_size)); $image_mode = sanitize_key((string) $image_mode); if (!in_array($image_mode, ['now', 'deferred', 'skip'], true)) $image_mode = 'deferred'; $id = pathinfo((string) $path, PATHINFO_FILENAME); $job = array_merge($preflight, [ 'id' => $id, 'file_name' => sanitize_file_name((string) $name), 'path' => $path, 'status' => 'uploaded', 'import_status' => 'draft', 'batch_size' => $batch_size, 'image_mode' => $image_mode, 'cursor' => $preflight['data_offset'], 'row_number' => 0, 'processed' => 0, 'created' => 0, 'updated' => 0, 'skipped_duplicates' => 0, 'images_pending' => 0, 'images_done' => 0, 'images_failed' => 0, 'failures' => [], 'retry_offsets' => [], 'image_queue' => [], 'seen' => [], 'stage' => 'Headers validated — counting rows…', 'current_product' => '', 'log' => [], 'count_offset' => $preflight['count_offset'] ?? $preflight['data_offset'], 'count_complete' => false, 'created_at' => gmdate('c'), 'started_at' => null, 'completed_at' => null, ]); ada_dib_bulk_log($job, 'CSV uploaded. Headers OK. Counting rows in the background…'); ada_dib_bulk_save($job); return $job; } add_action('wp_ajax_ada_dib_bulk_count', function () { ada_dib_bulk_ajax_guard(); $id = sanitize_key((string) ($_POST['job_id'] ?? '')); $job = ada_dib_bulk_get($id); if (!is_array($job)) wp_send_json_error(['message' => 'Job not found.'], 404); try { @set_time_limit(30); ada_dib_bulk_count_tick($job); } catch (Throwable $error) { wp_send_json_error(['message' => $error->getMessage()], 500); } wp_send_json_success(['job' => ada_dib_bulk_public($job)]); }); add_action('wp_ajax_ada_dib_bulk_chunk_init', function () { ada_dib_bulk_ajax_guard(); $name = sanitize_file_name((string) ($_POST['file_name'] ?? 'upload.csv')); $size = (int) ($_POST['file_size'] ?? 0); $total_chunks = (int) ($_POST['total_chunks'] ?? 0); $resume_id = sanitize_key((string) ($_POST['resume_upload_id'] ?? '')); if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'csv') { wp_send_json_error(['message' => 'Only .csv files are accepted.'], 400); } if ($size < 1 || $size > ADA_DIB_BULK_MAX_ASSEMBLED) { wp_send_json_error(['message' => 'CSV must be between 1 byte and 512 MB.'], 400); } if ($total_chunks < 1 || $total_chunks > ADA_DIB_BULK_CHUNK_MAX_PARTS) { wp_send_json_error(['message' => 'Invalid chunk count.'], 400); } // Resume an existing incomplete chunk session after refresh/power loss. if ($resume_id !== '') { $meta = get_option(ADA_DIB_BULK_CHUNK_OPT . $resume_id, null); if (is_array($meta) && (string) ($meta['file_name'] ?? '') === $name && (int) ($meta['file_size'] ?? 0) === $size && (int) ($meta['total_chunks'] ?? 0) === $total_chunks ) { $received = []; foreach ((array) ($meta['received'] ?? []) as $key => $val) { if ($val) $received[] = (int) $key; } sort($received); wp_send_json_success([ 'upload_id' => $resume_id, 'chunk_max' => ADA_DIB_BULK_CHUNK_BYTES, 'received' => $received, 'resumed' => true, ]); } } $dir = ada_dib_bulk_secure_dir(); if (is_wp_error($dir)) wp_send_json_error(['message' => $dir->get_error_message()], 500); $upload_id = str_replace('-', '', wp_generate_uuid4()); $chunk_dir = $dir . '/chunks_' . $upload_id; if (!wp_mkdir_p($chunk_dir)) { wp_send_json_error(['message' => 'Could not create chunk folder.'], 500); } $meta = [ 'upload_id' => $upload_id, 'file_name' => $name, 'file_size' => $size, 'total_chunks' => $total_chunks, 'received' => [], 'chunk_dir' => $chunk_dir, 'batch_size' => max(1, min(25, (int) ($_POST['batch_size'] ?? 5))), 'image_mode' => sanitize_key((string) ($_POST['image_mode'] ?? 'deferred')), 'created_at' => time(), 'user_id' => get_current_user_id(), ]; update_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id, $meta, false); wp_send_json_success([ 'upload_id' => $upload_id, 'chunk_max' => ADA_DIB_BULK_CHUNK_BYTES, 'received' => [], 'resumed' => false, ]); }); add_action('wp_ajax_ada_dib_bulk_chunk_status', function () { ada_dib_bulk_ajax_guard(); $upload_id = sanitize_key((string) ($_POST['upload_id'] ?? '')); $meta = get_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id, null); if (!is_array($meta)) wp_send_json_error(['message' => 'Chunk upload session not found.'], 404); $received = []; foreach ((array) ($meta['received'] ?? []) as $key => $val) { if ($val) $received[] = (int) $key; } sort($received); wp_send_json_success([ 'upload_id' => $upload_id, 'file_name' => $meta['file_name'] ?? '', 'file_size' => (int) ($meta['file_size'] ?? 0), 'total_chunks' => (int) ($meta['total_chunks'] ?? 0), 'received' => $received, 'received_count' => count($received), ]); }); add_action('wp_ajax_ada_dib_bulk_chunk_upload', function () { ada_dib_bulk_ajax_guard(); @set_time_limit(120); $upload_id = sanitize_key((string) ($_POST['upload_id'] ?? '')); $index = (int) ($_POST['chunk_index'] ?? -1); $meta = get_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id, null); if (!is_array($meta)) wp_send_json_error(['message' => 'Chunk upload session not found.'], 404); if ($index < 0 || $index >= (int) $meta['total_chunks']) { wp_send_json_error(['message' => 'Invalid chunk index.'], 400); } $dest = trailingslashit($meta['chunk_dir']) . sprintf('part_%05d.bin', $index); // Idempotent: already have this chunk (resume after power loss). if (!empty($meta['received'][$index]) && is_file($dest) && filesize($dest) > 0) { wp_send_json_success([ 'upload_id' => $upload_id, 'chunk_index' => $index, 'received' => count((array) $meta['received']), 'total_chunks' => (int) $meta['total_chunks'], 'skipped' => true, ]); } if (empty($_FILES['chunk']) || !is_uploaded_file($_FILES['chunk']['tmp_name'])) { wp_send_json_error(['message' => 'Chunk file missing.'], 400); } $chunk = $_FILES['chunk']; if ((int) $chunk['size'] < 1 || (int) $chunk['size'] > ADA_DIB_BULK_CHUNK_BYTES + 65536) { wp_send_json_error(['message' => 'Chunk exceeds size limit.'], 400); } if (!move_uploaded_file($chunk['tmp_name'], $dest)) { wp_send_json_error(['message' => 'Could not store chunk.'], 500); } $meta['received'][$index] = true; $meta['updated_at'] = time(); update_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id, $meta, false); wp_send_json_success([ 'upload_id' => $upload_id, 'chunk_index' => $index, 'received' => count($meta['received']), 'total_chunks' => (int) $meta['total_chunks'], ]); }); add_action('wp_ajax_ada_dib_bulk_chunk_finalize', function () { ada_dib_bulk_ajax_guard(); @set_time_limit(180); $upload_id = sanitize_key((string) ($_POST['upload_id'] ?? '')); $meta = get_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id, null); if (!is_array($meta)) wp_send_json_error(['message' => 'Chunk upload session not found.'], 404); $total = (int) $meta['total_chunks']; for ($i = 0; $i < $total; $i++) { if (empty($meta['received'][$i])) { wp_send_json_error(['message' => 'Missing chunk ' . $i . '.'], 400); } } $dir = ada_dib_bulk_secure_dir(); if (is_wp_error($dir)) wp_send_json_error(['message' => $dir->get_error_message()], 500); $id = $upload_id; $path = $dir . '/' . $id . '.csv'; $out = fopen($path, 'wb'); if (!$out) wp_send_json_error(['message' => 'Could not assemble CSV.'], 500); $assembled = 0; for ($i = 0; $i < $total; $i++) { $part = trailingslashit($meta['chunk_dir']) . sprintf('part_%05d.bin', $i); $in = fopen($part, 'rb'); if (!$in) { fclose($out); @unlink($path); wp_send_json_error(['message' => 'Could not read chunk ' . $i . '.'], 500); } while (!feof($in)) { $buf = fread($in, 1024 * 1024); if ($buf === false) break; $assembled += strlen($buf); if ($assembled > ADA_DIB_BULK_MAX_ASSEMBLED) { fclose($in); fclose($out); @unlink($path); wp_send_json_error(['message' => 'Assembled CSV exceeds 512 MB.'], 400); } fwrite($out, $buf); } fclose($in); @unlink($part); } fclose($out); if (is_dir($meta['chunk_dir'])) { @rmdir($meta['chunk_dir']); } delete_option(ADA_DIB_BULK_CHUNK_OPT . $upload_id); $job = ada_dib_bulk_create_job_from_path($path, $meta['file_name'], $meta['batch_size'], $meta['image_mode']); if (is_wp_error($job)) { @unlink($path); wp_send_json_error(['message' => $job->get_error_message()], 400); } wp_send_json_success(['job' => ada_dib_bulk_public($job)]); }); function ada_dib_bulk_process_row(array &$job, array $row, $offset, $row_number) { $record = ada_dib_validate_record(ada_dib_bulk_record($row, $job['header_map'])); if (is_wp_error($record)) throw new RuntimeException($record->get_error_message()); $source = ada_dib_clean_url($record['source_url']); if ($source === '') throw new RuntimeException('Source URL is missing or invalid.'); $hash = md5($source); if (isset($job['seen'][$hash])) { $job['skipped_duplicates']++; return; } $job['stage'] = 'Importing product data'; $job['current_product'] = sanitize_text_field($record['title'] ?: $source); $images = $record['images']; if ($job['image_mode'] !== 'now') $record['images'] = []; $progress = function ($stage) use (&$job) { $job['stage'] = sanitize_text_field($stage); ada_dib_bulk_save($job); }; $result = ada_dib_import_product($record, $progress); $job['seen'][$hash] = true; if ($result['action'] === 'updated') $job['updated']++; else $job['created']++; if ($job['image_mode'] === 'deferred' && $images) { $job['image_queue'][] = [ 'product_id' => (int) $result['id'], 'title' => $record['title'], 'images' => array_slice(array_values($images), 0, 5), 'cursor' => 0, 'ids' => [], ]; $job['images_pending'] += count(array_slice($images, 0, 5)); } elseif ($job['image_mode'] === 'now') { $job['images_done'] += (int) $result['images']; $job['images_failed'] += count((array) $result['failed_images']); } } function ada_dib_bulk_core_tick(array &$job) { $path = ada_dib_bulk_valid_path($job); if (!$path) throw new RuntimeException('Stored CSV file is unavailable.'); $handle = fopen($path, 'rb'); if (!$handle) throw new RuntimeException('Stored CSV file could not be opened.'); $deadline = microtime(true) + 12; $limit = max(1, min(25, (int) $job['batch_size'])); $worked = 0; while ($worked < $limit && microtime(true) < $deadline) { if (ada_dib_bulk_apply_stop($job)) break; $retry = !empty($job['retry_offsets']); if ($retry) { $entry = array_shift($job['retry_offsets']); $offset = (int) $entry['offset']; $row_number = (int) $entry['row']; fseek($handle, $offset); } else { fseek($handle, (int) $job['cursor']); $offset = ftell($handle); $row_number = (int) $job['row_number'] + 1; } $row = fgetcsv($handle); if ($row === false) { if ($retry) continue; $job['status'] = $job['image_mode'] === 'deferred' && $job['image_queue'] ? 'images' : 'completed'; $job['stage'] = $job['status'] === 'images' ? 'Product data complete; images queued' : 'Completed'; if ($job['status'] === 'completed') $job['completed_at'] = gmdate('c'); break; } $next_offset = ftell($handle); if ($row === [null] || !array_filter($row, 'strlen')) { if (!$retry) { $job['cursor'] = $next_offset; $job['row_number'] = $row_number; } continue; } try { ada_dib_bulk_process_row($job, $row, $offset, $row_number); } catch (Throwable $error) { $job['failures'][] = [ 'row' => $row_number, 'offset' => $offset, 'error' => sanitize_text_field($error->getMessage()), ]; $job['failures'] = array_slice($job['failures'], -100); ada_dib_bulk_log($job, 'Row ' . $row_number . ' failed: ' . $error->getMessage()); } if (!$retry) { $job['cursor'] = $next_offset; $job['row_number'] = $row_number; $job['processed']++; } $worked++; if (ada_dib_bulk_apply_stop($job)) { ada_dib_bulk_save($job); break; } ada_dib_bulk_save($job); } fclose($handle); } function ada_dib_bulk_image_tick(array &$job) { if (ada_dib_bulk_apply_stop($job)) return; if (!$job['image_queue']) { $job['status'] = 'completed'; $job['stage'] = 'Completed'; $job['completed_at'] = gmdate('c'); return; } $item =& $job['image_queue'][0]; $index = (int) $item['cursor']; $total = count($item['images']); if ($index < $total) { $job['stage'] = sprintf('Downloading image %d/%d', $index + 1, $total); $job['current_product'] = sanitize_text_field($item['title']); try { $item['ids'][] = ada_dib_download_media($item['images'][$index], (int) $item['product_id'], $item['title']); $job['images_done']++; } catch (Throwable $error) { $job['images_failed']++; ada_dib_bulk_log($job, 'Image failed for product ' . $item['product_id'] . ': ' . $error->getMessage()); } $job['images_pending'] = max(0, (int) $job['images_pending'] - 1); $item['cursor']++; } if ((int) $item['cursor'] >= $total) { $object = wc_get_product((int) $item['product_id']); if ($object && $item['ids']) { $object->set_image_id((int) $item['ids'][0]); $object->set_gallery_image_ids(array_map('intval', array_slice($item['ids'], 1))); $object->set_status('draft'); $object->save(); } array_shift($job['image_queue']); } if (!$job['image_queue']) { $job['status'] = 'completed'; $job['stage'] = 'Completed'; $job['completed_at'] = gmdate('c'); } } /** * If another request paused/cancelled this job, copy that status onto $job and return true. */ function ada_dib_bulk_apply_stop(array &$job) { $fresh = ada_dib_bulk_get($job['id'] ?? ''); if (!is_array($fresh)) return false; if (!in_array((string) ($fresh['status'] ?? ''), ['paused', 'cancelled'], true)) return false; $job['status'] = $fresh['status']; $job['stage'] = $fresh['stage'] ?? $job['stage']; if (isset($fresh['resume_stage'])) $job['resume_stage'] = $fresh['resume_stage']; if (!empty($fresh['completed_at'])) $job['completed_at'] = $fresh['completed_at']; return true; } add_action('wp_ajax_ada_dib_bulk_worker', function () { ada_dib_bulk_ajax_guard(); $id = sanitize_key((string) ($_POST['job_id'] ?? '')); $job = ada_dib_bulk_get($id); if (!is_array($job)) wp_send_json_error(['message' => 'Import job not found.'], 404); if (!in_array($job['status'], ['running', 'images'], true)) { wp_send_json_success(['job' => ada_dib_bulk_public($job)]); } $lock = 'ada_dib_bulk_lock_' . md5($id); $locked_at = (int) get_option($lock, 0); if ($locked_at && time() - $locked_at < 30) { wp_send_json_success(['job' => ada_dib_bulk_public($job), 'locked' => true]); } delete_option($lock); if (!add_option($lock, time(), '', false)) { wp_send_json_success(['job' => ada_dib_bulk_public($job), 'locked' => true]); } try { if ($job['status'] === 'images') ada_dib_bulk_image_tick($job); else ada_dib_bulk_core_tick($job); ada_dib_bulk_apply_stop($job); ada_dib_bulk_save($job); } catch (Throwable $error) { if (!ada_dib_bulk_apply_stop($job)) { $job['status'] = 'paused'; $job['stage'] = 'Paused after worker error'; ada_dib_bulk_log($job, $error->getMessage()); } ada_dib_bulk_save($job); } finally { delete_option($lock); } wp_send_json_success(['job' => ada_dib_bulk_public($job)]); }); add_action('wp_ajax_ada_dib_bulk_control', function () { ada_dib_bulk_ajax_guard(); $id = sanitize_key((string) ($_POST['job_id'] ?? '')); $operation = sanitize_key((string) ($_POST['operation'] ?? 'status')); $job = ada_dib_bulk_get($id); if (!is_array($job)) wp_send_json_error(['message' => 'Import job not found.'], 404); $changed = false; if ($operation === 'start' && $job['status'] === 'uploaded') { if (!empty($job['missing_headers'])) wp_send_json_error(['message' => 'Required CSV headers are missing.'], 400); $job['status'] = 'running'; $job['stage'] = 'Starting product data import'; $job['started_at'] = gmdate('c'); ada_dib_bulk_log($job, 'Draft-only import started.'); $changed = true; } elseif ($operation === 'pause' && in_array($job['status'], ['running', 'images'], true)) { $job['resume_stage'] = $job['status']; $job['status'] = 'paused'; $job['stage'] = 'Paused'; ada_dib_bulk_log($job, 'Import paused by user.'); $changed = true; } elseif ($operation === 'resume' && $job['status'] === 'paused') { $job['status'] = ($job['resume_stage'] ?? '') === 'images' ? 'images' : 'running'; $job['stage'] = 'Resuming'; ada_dib_bulk_log($job, 'Import resumed by user.'); $changed = true; } elseif ($operation === 'cancel' && !in_array($job['status'], ['completed', 'cancelled'], true)) { $job['status'] = 'cancelled'; $job['stage'] = 'Cancelled'; $job['completed_at'] = gmdate('c'); ada_dib_bulk_log($job, 'Import cancelled by user.'); $changed = true; } elseif ($operation === 'retry' && !empty($job['failures'])) { $job['retry_offsets'] = array_values($job['failures']); $job['failures'] = []; $job['status'] = 'running'; $job['stage'] = 'Retrying failed rows'; $job['completed_at'] = null; ada_dib_bulk_log($job, 'Retrying failed rows.'); $changed = true; } elseif ($operation === 'delete') { if (in_array($job['status'], ['running', 'images'], true)) { wp_send_json_error(['message' => 'Pause or cancel the job before deleting it.'], 409); } $path = ada_dib_bulk_valid_path($job); if ($path) @unlink($path); delete_option(ada_dib_bulk_job_key($id)); $ids = array_values(array_diff((array) get_option(ADA_DIB_BULK_INDEX, []), [$id])); update_option(ADA_DIB_BULK_INDEX, $ids, false); wp_send_json_success(['deleted' => true]); } else { wp_send_json_error([ 'message' => sprintf( 'Cannot %s while job status is "%s".', $operation, (string) ($job['status'] ?? 'unknown') ), ], 409); } if (!$changed) { wp_send_json_error(['message' => 'No change applied.'], 409); } ada_dib_bulk_save($job); wp_send_json_success(['job' => ada_dib_bulk_public($job)]); }); add_action('ada_dib_bulk_fallback', function () { foreach ((array) get_option(ADA_DIB_BULK_INDEX, []) as $id) { $job = ada_dib_bulk_get($id); if (!is_array($job) || !in_array($job['status'], ['running', 'images'], true)) continue; $lock = 'ada_dib_bulk_lock_' . md5($id); if (!add_option($lock, time(), '', false)) return; try { if ($job['status'] === 'images') ada_dib_bulk_image_tick($job); else ada_dib_bulk_core_tick($job); ada_dib_bulk_apply_stop($job); ada_dib_bulk_save($job); } catch (Throwable $ignored) { } finally { delete_option($lock); } break; } }); add_action('init', function () { if (!wp_next_scheduled('ada_dib_bulk_fallback')) { wp_schedule_event(time() + 120, 'ada_dib_minute', 'ada_dib_bulk_fallback'); } }); add_action('admin_menu', function () { add_submenu_page( 'edit.php?post_type=product', 'ADA Bulk Import', 'ADA Bulk Import', 'manage_woocommerce', 'ada-bulk-import', 'ada_dib_bulk_admin_page' ); }); function ada_dib_bulk_admin_page() { if (!current_user_can('manage_woocommerce')) return; $jobs = []; foreach ((array) get_option(ADA_DIB_BULK_INDEX, []) as $id) { $job = ada_dib_bulk_get($id); if (is_array($job)) $jobs[] = ada_dib_bulk_public($job); } $config = [ 'ajax' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('ada_dib_bulk'), 'jobs' => $jobs, 'directMax' => ADA_DIB_BULK_MAX_FILE, 'assembledMax' => ADA_DIB_BULK_MAX_ASSEMBLED, 'chunkBytes' => ADA_DIB_BULK_CHUNK_BYTES, ]; echo '

ADA Bulk Import

'; echo '

Draft-only safety: uploads never publish. Import starts only when you click Start. Jobs keep a cursor so you can resume after a refresh or power cut; use Retry Failed for rows that errored. Large CSV uploads use resumable 5 MB chunks (survives refresh/power loss if you re-select the same file).

'; echo ''; echo ''; echo '

Upload CSV

'; echo ''; echo ''; echo ''; echo '
'; echo '

Up to 512 MB. Files over 5 MB upload in resumable 5 MB chunks. After a power cut/refresh, re-select the same file and use Resume Upload. UTF-8 BOM and quoted multiline fields are supported. For offline prep, use Split CSV in the desktop scraper (~80 MB parts).

Import status

Rows per bounded worker tick; default 5 for shared hosting.

'; submit_button('Upload & Preflight', 'primary', 'submit', false); echo '
'; echo '

Selected job

Select a job from history or upload a CSV.
'; echo '

'; echo ' '; echo ' '; echo '

'; echo '

Current stage:
'; echo 'Current product:

'; echo '

Import history

'; echo ''; if (!$jobs) echo ''; foreach ($jobs as $job) { echo ''; echo ''; echo ''; } echo '
File / JobStatusProgressUpdated
No CSV jobs yet.
' . esc_html($job['file_name']) . '
' . esc_html(substr($job['id'], 0, 12)) . '
' . esc_html($job['status']) . '' . esc_html($job['processed'] . '/' . $job['total']) . '' . esc_html($job['updated_at']) . '
'; echo ''; echo ''; ?>